Client hooks provide lifecycle callbacks for custom client-side JavaScript when elements are added, updated, or removed by the server.
Overview
Hooks allow you to:
- Initialize third-party JavaScript libraries
- Manage client-side state
- Handle complex DOM interactions
- Integrate with external APIs
- Build custom UI components
For simpler client-side operations, consider using JS Commands instead of hooks.
Basic Hook Setup
1. Define the Hook
/**
* @type {import("phoenix_live_view").HooksOptions}
*/
let Hooks = {}
Hooks.PhoneNumber = {
mounted() {
this.el.addEventListener("input", e => {
let match = this.el.value.replace(/\D/g, "").match(/^(\d{3})(\d{3})(\d{4})$/)
if(match) {
this.el.value = `${match[1]}-${match[2]}-${match[3]}`
}
})
}
}
let liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks})
2. Use in Template
<input type="text"
name="user[phone_number]"
id="user-phone-number"
phx-hook="PhoneNumber" />
When using phx-hook, a unique DOM ID must always be set.
Lifecycle Callbacks
mounted
updated
beforeUpdate
destroyed
disconnected
reconnected
Called when element is added to DOM and LiveView has finished mounting:mounted() {
console.log("Element mounted:", this.el)
// Initialize libraries, add event listeners
}
Outside a LiveView context, only mounted is invoked for elements present at DOM ready.
Called when element has been updated by the server:updated() {
console.log("Element updated")
// Refresh UI, sync state
}
window.location may not reflect current URL during this callback. Use phx:navigate event for navigation-aware logic.
Called just before element is updated. Must be synchronous:beforeUpdate() {
// Save scroll position, prepare for update
this.scrollTop = this.el.scrollTop
}
This callback must be synchronous—it cannot be deferred or cancelled.
Called when element is removed from the page:destroyed() {
console.log("Element destroyed")
// Cleanup resources, remove listeners
}
Called when parent LiveView disconnects from server:disconnected() {
console.log("Connection lost")
// Pause animations, show offline UI
}
Called when parent LiveView reconnects to server:reconnected() {
console.log("Connection restored")
// Resume animations, hide offline UI
}
Hook Attributes
Hooks have access to these attributes:
| Attribute | Description |
|---|
this.el | The bound DOM node |
this.liveSocket | The LiveSocket instance |
Hook Methods
Push Events
Push events to the LiveView server:// With callback
this.pushEvent("save", {data: "value"}, (reply, ref) => {
console.log("Server replied:", reply)
})
// Returns promise if no callback
const reply = await this.pushEvent("save", {data: "value"})
Push to specific LiveView or LiveComponent:// Using selector
this.pushEventTo("#my-component", "update", {value: 42})
// Using element reference
this.pushEventTo(this.el, "update", {value: 42})
// Returns Promise.allSettled() if multiple targets
const results = await this.pushEventTo(".components", "update", {})
Handle Events
handleEvent
removeHandleEvent
Handle events pushed from server:mounted() {
this.handleEvent("highlight", ({duration}) => {
this.el.classList.add("highlight")
setTimeout(() => {
this.el.classList.remove("highlight")
}, duration)
})
}
Remove an event handler:mounted() {
this.ref = this.handleEvent("myevent", callback)
}
destroyed() {
this.removeHandleEvent(this.ref)
}
Upload Methods
Inject files into an uploader:this.upload("avatar", files)
Upload to specific LiveView/Component:this.uploadTo("#my-component", "avatar", files)
JS Commands
Access JS command interface:
mounted() {
this.js()
.show(this.el, {transition: "fade-in"})
.addClass(this.el, "active")
}
Complete Hook Examples
Chart Integration
<div id="chart" phx-hook="Chart" data-points={Jason.encode!(@points)}></div>
<div id="infinite-scroll"
phx-hook="InfiniteScroll"
data-page={@page}>
<!-- Scrollable content -->
</div>
Map Integration
Hooks.Map = {
mounted() {
const {lat, lng, zoom} = this.el.dataset
this.map = new MapLibrary.Map(this.el, {
center: [parseFloat(lat), parseFloat(lng)],
zoom: parseInt(zoom)
})
this.map.on("moveend", () => {
const center = this.map.getCenter()
this.pushEvent("map-moved", {
lat: center.lat,
lng: center.lng,
zoom: this.map.getZoom()
})
})
this.handleEvent("add-marker", ({lat, lng, title}) => {
new MapLibrary.Marker({lat, lng})
.setTitle(title)
.addTo(this.map)
})
},
destroyed() {
this.map.remove()
}
}
Colocated Hooks
Define hooks next to component code:
def phone_number_input(assigns) do
~H"""
<input type="text"
name="user[phone_number]"
id="user-phone-number"
phx-hook=".PhoneNumber" />
<script :type={Phoenix.LiveView.ColocatedHook} name=".PhoneNumber">
export default {
mounted() {
this.el.addEventListener("input", e => {
let match = this.el.value.replace(/\D/g, "").match(/^(\d{3})(\d{3})(\d{4})$/)
if(match) {
this.el.value = `${match[1]}-${match[2]}-${match[3]}`
}
})
}
}
</script>
"""
end
Importing Colocated Hooks
import {hooks as colocatedHooks} from "phoenix-colocated/my_app"
let liveSocket = new LiveSocket("/live", Socket, {
hooks: {...colocatedHooks, ...Hooks}
})
Colocated hooks use dot syntax (.HookName) and are automatically namespaced by module name.
Class-Based Hooks
Define hooks as classes:
import { ViewHook } from "phoenix_live_view"
class DataTable extends ViewHook {
constructor() {
super()
this.page = 1
}
mounted() {
this.initTable()
}
initTable() {
// Initialize DataTable library
}
updated() {
this.refreshTable()
}
destroyed() {
this.cleanup()
}
}
let liveSocket = new LiveSocket(..., {
hooks: { DataTable }
})
Advanced: DOM Integration
Preserve client-side DOM modifications across server updates:
let liveSocket = new LiveSocket("/live", Socket, {
hooks: Hooks,
dom: {
onBeforeElUpdated(from, to) {
// Preserve data-js-* attributes
for (const attr of from.attributes) {
if (attr.name.startsWith("data-js-")) {
to.setAttribute(attr.name, attr.value)
}
}
// Preserve scroll position
if (from.scrollTop > 0) {
to.scrollTop = from.scrollTop
}
}
}
})
onBeforeElUpdated is called just before patching—it cannot be deferred or cancelled.
Best Practices
Resource Cleanup
Event Scoping
State Management
TypeScript Support
Always clean up in destroyed():mounted() {
this.timer = setInterval(() => this.update(), 1000)
this.listener = () => this.handleResize()
window.addEventListener("resize", this.listener)
}
destroyed() {
clearInterval(this.timer)
window.removeEventListener("resize", this.listener)
}
Namespace events for components with siblings:mounted() {
this.handleEvent(`update-${this.el.id}`, callback)
}
push_event(socket, "update-#{id}", data)
Store state on the hook instance:mounted() {
this.state = {count: 0, active: false}
}
updated() {
// Access this.state
}
Use TypeScript definitions:import type { Hook } from "phoenix_live_view"
const MyHook: Hook = {
mounted() {
// Type-safe hook code
}
}
Common Patterns
Debounced Push
Hooks.Search = {
mounted() {
this.timeout = null
this.el.addEventListener("input", e => {
clearTimeout(this.timeout)
this.timeout = setTimeout(() => {
this.pushEvent("search", {query: e.target.value})
}, 300)
})
}
}
Two-way Data Binding
Hooks.Slider = {
mounted() {
// Client to server
this.el.addEventListener("change", e => {
this.pushEvent("value-changed", {value: e.target.value})
})
// Server to client
this.handleEvent(`update-${this.el.id}`, ({value}) => {
this.el.value = value
})
}
}
Loading States
Hooks.AsyncButton = {
mounted() {
this.el.addEventListener("click", async e => {
this.el.disabled = true
this.el.textContent = "Loading..."
try {
await this.pushEvent("submit", {})
} finally {
this.el.disabled = false
this.el.textContent = "Submit"
}
})
}
}
See Also